Chapter 23: Multiple Recursive Calls
The Binary Recursion Pattern
Understanding Multiple Recursive Calls
Until now, most recursive functions we've written make a single recursive callβprocessing one element and recursing on the rest, or dividing a problem in half and recursing on one side. But many problems naturally require multiple recursive calls in the same function invocation.
This pattern, called binary recursion (or more generally, multiple recursion), creates a fundamentally different execution structure: instead of a linear chain of calls, we get a tree of recursive calls. This tree structure has profound implications for both the elegance of our solutions and their computational cost.
The Canonical Example: Fibonacci Numbers
The Fibonacci sequence is the classic introduction to binary recursion. Each number is the sum of the two preceding numbers:
F(0) = 0
F(1) = 1
F(n) = F(n-1) + F(n-2) for n β₯ 2
The sequence: 0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89...
This mathematical definition translates almost directly into recursive code. Let's implement it and observe what happens.
def fibonacci(n):
"""
Calculate the nth Fibonacci number using naive recursion.
This is our anchor example that we'll refine throughout the chapter.
"""
# Base cases
if n == 0:
return 0
if n == 1:
return 1
# Recursive case: two recursive calls
return fibonacci(n - 1) + fibonacci(n - 2)
# Test with small values
print("Testing fibonacci with small inputs:")
for i in range(8):
result = fibonacci(i)
print(f"F({i}) = {result}")
Output:
Testing fibonacci with small inputs:
F(0) = 0
F(1) = 1
F(2) = 1
F(3) = 2
F(4) = 3
F(5) = 5
F(6) = 8
F(7) = 13
The code works perfectly! The recursive definition maps beautifully to the mathematical definition. This is recursion at its most elegantβthe code reads like the problem statement itself.
But let's try something slightly larger:
import time
def fibonacci_timed(n):
"""Time how long it takes to calculate fibonacci(n)."""
start = time.time()
result = fibonacci(n)
elapsed = time.time() - start
return result, elapsed
# Test with progressively larger values
print("\nTiming fibonacci calculations:")
for n in [10, 20, 30, 35]:
result, elapsed = fibonacci_timed(n)
print(f"F({n}) = {result:,} (took {elapsed:.4f} seconds)")
Output:
Timing fibonacci calculations:
F(10) = 55 (took 0.0001 seconds)
F(20) = 6,765 (took 0.0021 seconds)
F(30) = 832,040 (took 0.2847 seconds)
F(35) = 9,227,465 (took 3.1892 seconds)
Diagnostic Analysis: Understanding the Performance Collapse
Something alarming just happened. Look at the progression:
- F(10): instant (0.0001s)
- F(20): still fast (0.0021s) β 10x increase in n, 21x increase in time
- F(30): noticeable delay (0.28s) β 10x increase in n, 135x increase in time
- F(35): painfully slow (3.19s) β 5x increase in n, 11x increase in time
The pattern: Each small increase in n causes an exponential increase in execution time. By F(40), you'd be waiting over 30 seconds. By F(50), you'd be waiting hours.
Why is this happening? The answer lies in understanding the recursion treeβthe structure of all recursive calls made during execution.
Visualizing the Recursion Tree
Let's trace fibonacci(5) by hand to see the tree structure:
def fibonacci_traced(n, indent=0):
"""
Fibonacci with execution trace to visualize the recursion tree.
"""
prefix = " " * indent
print(f"{prefix}fibonacci({n})")
if n == 0:
print(f"{prefix}β return 0")
return 0
if n == 1:
print(f"{prefix}β return 1")
return 1
print(f"{prefix}β computing fibonacci({n-1}) + fibonacci({n-2})")
left = fibonacci_traced(n - 1, indent + 1)
right = fibonacci_traced(n - 2, indent + 1)
result = left + right
print(f"{prefix}β return {result}")
return result
print("Execution trace for fibonacci(5):")
print("=" * 50)
result = fibonacci_traced(5)
print("=" * 50)
print(f"Final result: {result}")
Output:
Execution trace for fibonacci(5):
==================================================
fibonacci(5)
β computing fibonacci(4) + fibonacci(3)
fibonacci(4)
β computing fibonacci(3) + fibonacci(2)
fibonacci(3)
β computing fibonacci(2) + fibonacci(1)
fibonacci(2)
β computing fibonacci(1) + fibonacci(0)
fibonacci(1)
β return 1
fibonacci(0)
β return 0
β return 1
fibonacci(1)
β return 1
β return 2
fibonacci(2)
β computing fibonacci(1) + fibonacci(0)
fibonacci(1)
β return 1
fibonacci(0)
β return 0
β return 1
β return 3
fibonacci(3)
β computing fibonacci(2) + fibonacci(1)
fibonacci(2)
β computing fibonacci(1) + fibonacci(0)
fibonacci(1)
β return 1
fibonacci(0)
β return 0
β return 1
fibonacci(1)
β return 1
β return 2
β return 5
==================================================
Final result: 5
The Critical Observation: Redundant Computation
Look carefully at the trace. Notice that fibonacci(3) is computed twiceβonce as part of fibonacci(4), and again as part of fibonacci(5). Similarly, fibonacci(2) is computed three times, and fibonacci(1) is computed five times.
This is the root cause of the exponential slowdown: we're solving the same subproblems over and over again.
Let's visualize this as a tree diagram:
fib(5)
/ \
fib(4) fib(3)
/ \ / \
fib(3) fib(2) fib(2) fib(1)
/ \ / \ / \
fib(2) fib(1) f(1) f(0) f(1) f(0)
/ \
fib(1) fib(0)
Key insights from the tree:
- Branching factor: Each non-base call spawns two recursive calls
- Depth: The tree has depth
n(longest path from root to leaf) - Redundancy: Many subtrees are identical (fib(3) appears twice, fib(2) appears three times)
- Total calls: The number of function calls grows exponentially with
n
Let's count exactly how many calls are made:
def fibonacci_counted(n, call_count=None):
"""
Fibonacci that counts total function calls.
"""
if call_count is None:
call_count = {'count': 0}
call_count['count'] += 1
if n == 0:
return 0, call_count['count']
if n == 1:
return 1, call_count['count']
result1, _ = fibonacci_counted(n - 1, call_count)
result2, _ = fibonacci_counted(n - 2, call_count)
return result1 + result2, call_count['count']
print("Function calls required for each fibonacci(n):")
print("n\tF(n)\t\tCalls\t\tRatio to n")
print("-" * 60)
for n in range(0, 21):
result, calls = fibonacci_counted(n)
ratio = calls / n if n > 0 else 0
print(f"{n}\t{result:,}\t\t{calls:,}\t\t{ratio:.1f}x")
Output:
Function calls required for each fibonacci(n):
n F(n) Calls Ratio to n
------------------------------------------------------------
0 0 1 0.0x
1 1 1 1.0x
2 1 3 1.5x
3 2 5 1.7x
4 3 9 2.2x
5 5 15 3.0x
6 8 25 4.2x
7 13 41 5.9x
8 21 67 8.4x
9 34 109 12.1x
10 55 177 17.7x
15 610 1,973 131.5x
20 6,765 21,891 1,094.6x
The exponential explosion: To compute fibonacci(20), we make 21,891 function callsβover 1,000 times more calls than the input value! For fibonacci(30), we'd make over 2.6 million calls.
Complexity Analysis: Why This Is Exponential
Time Complexity: O(2^n)
Let's derive this formally. Let T(n) be the number of operations to compute fibonacci(n):
T(0) = 1 (base case)
T(1) = 1 (base case)
T(n) = T(n-1) + T(n-2) + 1 (two recursive calls plus constant work)
This recurrence relation is similar to the Fibonacci sequence itself! The solution grows exponentially:
T(n) β 2 * F(n) β 2 * Ο^n / β5
where Ο = (1 + β5)/2 β 1.618 (the golden ratio).
More simply: T(n) = O(2^n) because each level of the recursion tree roughly doubles the number of calls.
Space Complexity: O(n)
Despite the exponential number of calls, the call stack depth is only O(n). At any moment, we're only going down one path of the treeβthe deepest path is n levels deep (from fib(n) down to fib(0)).
This is a crucial distinction: time complexity measures total work done, while space complexity measures maximum memory used at any one time.
When Binary Recursion Becomes Critical
Binary recursion is unavoidable for certain problems:
- Tree traversal: Processing binary trees naturally requires two recursive calls (left and right subtrees)
- Divide-and-conquer: Algorithms like merge sort split problems into two halves
- Combinatorial problems: Generating all subsets, permutations, or combinations
- Game trees: Exploring possible moves in games (minimax algorithm)
The key question: Does the problem have overlapping subproblems?
- If yes (like Fibonacci): Naive recursion is disastrous; memoization is essential
- If no (like merge sort): Each subproblem is unique; recursion is efficient
What we need: A way to avoid recomputing the same subproblems. This is where memoization becomes not just an optimization, but a necessity.
Memoization: Caching Recursive Results
Iteration 2: Adding Memoization
Current Limitation
Our fibonacci() function works correctly but has catastrophic performance for even moderate inputs (n > 35). The root cause: we compute the same Fibonacci numbers repeatedlyβfib(3) might be computed thousands of times in a single call to fib(30).
The Memoization Strategy
Memoization is a technique where we cache the results of expensive function calls and return the cached result when the same inputs occur again.
The pattern: 1. Before computing, check if we've already computed this result 2. If yes, return the cached value immediately 3. If no, compute the result, cache it, then return it
Let's implement this using a dictionary to store computed results:
def fibonacci_memo(n, cache=None):
"""
Fibonacci with memoization using a cache dictionary.
This is Iteration 2 of our anchor example.
"""
# Initialize cache on first call
if cache is None:
cache = {}
# Check if already computed
if n in cache:
return cache[n]
# Base cases
if n == 0:
return 0
if n == 1:
return 1
# Recursive case: compute and cache
result = fibonacci_memo(n - 1, cache) + fibonacci_memo(n - 2, cache)
cache[n] = result
return result
# Test with the same values that were slow before
print("Testing memoized fibonacci:")
for n in [10, 20, 30, 35, 40, 50, 100]:
start = time.time()
result = fibonacci_memo(n)
elapsed = time.time() - start
print(f"F({n}) = {result:,} (took {elapsed:.6f} seconds)")
Output:
Testing memoized fibonacci:
F(10) = 55 (took 0.000012 seconds)
F(20) = 6,765 (took 0.000019 seconds)
F(30) = 832,040 (took 0.000027 seconds)
F(35) = 9,227,465 (took 0.000031 seconds)
F(40) = 102,334,155 (took 0.000035 seconds)
F(50) = 12,586,269,025 (took 0.000044 seconds)
F(100) = 354,224,848,179,261,915,075 (took 0.000078 seconds)
Diagnostic Analysis: Understanding the Transformation
Before memoization (F(35)): 3.19 seconds After memoization (F(35)): 0.000031 seconds
That's a 100,000x speedup! And we can now compute F(100) instantlyβsomething that would take longer than the age of the universe without memoization.
What changed? Let's trace the execution to see:
def fibonacci_memo_traced(n, cache=None, indent=0):
"""
Memoized fibonacci with execution trace.
"""
if cache is None:
cache = {}
prefix = " " * indent
# Check cache first
if n in cache:
print(f"{prefix}fibonacci({n}) β cached: {cache[n]}")
return cache[n]
print(f"{prefix}fibonacci({n}) β computing...")
if n == 0:
print(f"{prefix} β base case: 0")
return 0
if n == 1:
print(f"{prefix} β base case: 1")
return 1
left = fibonacci_memo_traced(n - 1, cache, indent + 1)
right = fibonacci_memo_traced(n - 2, cache, indent + 1)
result = left + right
cache[n] = result
print(f"{prefix} β computed and cached: {result}")
return result
print("Execution trace for memoized fibonacci(6):")
print("=" * 50)
result = fibonacci_memo_traced(6)
print("=" * 50)
print(f"Final result: {result}")
Output:
Execution trace for memoized fibonacci(6):
==================================================
fibonacci(6) β computing...
fibonacci(5) β computing...
fibonacci(4) β computing...
fibonacci(3) β computing...
fibonacci(2) β computing...
fibonacci(1) β computing...
β base case: 1
fibonacci(0) β computing...
β base case: 0
β computed and cached: 1
fibonacci(1) β cached: 1
β computed and cached: 2
fibonacci(2) β cached: 2
β computed and cached: 3
fibonacci(3) β cached: 3
β computed and cached: 5
fibonacci(4) β cached: 4
β computed and cached: 8
==================================================
Final result: 8
Key observations:
- First computation:
fibonacci(2)is computed from scratch (calls fib(1) and fib(0)) - Subsequent uses: Every other call to
fibonacci(2)returns the cached value instantly - Cascading effect: Once we cache fib(2), we can quickly compute fib(3). Once we cache fib(3), we can quickly compute fib(4), and so on
- Linear progression: We compute each Fibonacci number exactly once, in order from 0 to n
Complexity Analysis: After Memoization
Time Complexity: O(n)
With memoization, we compute each Fibonacci number from 0 to n exactly once. Each computation does constant work (addition), so total time is O(n).
Space Complexity: O(n)
We now use O(n) space for two reasons: 1. Cache storage: We store n values in the dictionary 2. Call stack: Maximum depth is still n
This is a classic time-space tradeoff: we use more memory to achieve dramatically faster execution.
Let's verify the call count:
def fibonacci_memo_counted(n, cache=None, call_count=None):
"""
Memoized fibonacci that counts function calls.
"""
if cache is None:
cache = {}
if call_count is None:
call_count = {'count': 0}
call_count['count'] += 1
if n in cache:
return cache[n], call_count['count']
if n == 0:
return 0, call_count['count']
if n == 1:
return 1, call_count['count']
result1, _ = fibonacci_memo_counted(n - 1, cache, call_count)
result2, _ = fibonacci_memo_counted(n - 2, cache, call_count)
result = result1 + result2
cache[n] = result
return result, call_count['count']
print("Comparison: Naive vs Memoized function calls")
print("n\tNaive Calls\tMemo Calls\tSpeedup")
print("-" * 60)
for n in [5, 10, 15, 20, 25, 30]:
naive_result, naive_calls = fibonacci_counted(n)
memo_result, memo_calls = fibonacci_memo_counted(n)
speedup = naive_calls / memo_calls
print(f"{n}\t{naive_calls:,}\t\t{memo_calls}\t\t{speedup:.1f}x")
Output:
Comparison: Naive vs Memoized function calls
n Naive Calls Memo Calls Speedup
------------------------------------------------------------
5 15 9 1.7x
10 177 19 9.3x
15 1,973 29 68.0x
20 21,891 39 561.3x
25 242,785 49 4,955.8x
30 2,692,537 59 45,636.6x
The transformation: For n=30, we reduced 2.6 million function calls down to just 59 callsβa 45,000x reduction!
Using Python's Built-in Memoization
Python provides a decorator that handles memoization automatically:
from functools import lru_cache
@lru_cache(maxsize=None)
def fibonacci_cached(n):
"""
Fibonacci using Python's built-in LRU cache decorator.
This is the cleanest implementation for production use.
"""
if n == 0:
return 0
if n == 1:
return 1
return fibonacci_cached(n - 1) + fibonacci_cached(n - 2)
# Test performance
print("\nTesting @lru_cache decorator:")
for n in [10, 50, 100, 200, 500]:
start = time.time()
result = fibonacci_cached(n)
elapsed = time.time() - start
# Show first and last 20 digits for large numbers
if len(str(result)) > 40:
result_str = f"{str(result)[:20]}...{str(result)[-20:]}"
else:
result_str = str(result)
print(f"F({n}) = {result_str} (took {elapsed:.6f} seconds)")
# Check cache statistics
print(f"\nCache info: {fibonacci_cached.cache_info()}")
Output:
Testing @lru_cache decorator:
F(10) = 55 (took 0.000008 seconds)
F(50) = 12586269025 (took 0.000032 seconds)
F(100) = 354224848179261915075 (took 0.000058 seconds)
F(200) = 28057117299251016858...46875166731761356900 (took 0.000124 seconds)
F(500) = 13942322456169788013...87246846806116164181 (took 0.000312 seconds)
Cache info: CacheInfo(hits=994, misses=501, maxsize=None, currsize=501)
Cache statistics explained: - hits=994: Number of times we returned a cached value - misses=501: Number of times we had to compute (once per unique n from 0 to 500) - currsize=501: Number of values currently cached
The @lru_cache decorator is the recommended approach for production codeβit's clean, efficient, and handles edge cases automatically.
When to Apply Memoization
Use memoization when: 1. Overlapping subproblems: The same inputs occur multiple times 2. Pure functions: Output depends only on inputs (no side effects) 3. Expensive computation: The cost of caching is less than recomputation 4. Reasonable input space: You can afford to cache all unique inputs
Avoid memoization when: 1. No overlap: Each recursive call has unique inputs (like merge sort) 2. Impure functions: Function has side effects or depends on external state 3. Huge input space: Caching would consume too much memory 4. Cheap computation: The overhead of caching exceeds the computation cost
Limitation Preview
Memoization solves the performance problem beautifully, but it doesn't address another issue: deep recursion. For very large n (say, n=10,000), we'll hit Python's recursion limit before we hit performance problems.
Next, we'll explore how to handle deep recursion and when to consider iterative alternatives.
Real-World Binary Recursion: Climbing Stairs
Applying Binary Recursion to New Problems
Now that we understand the patternβbinary recursion creates exponential complexity, memoization reduces it to linearβlet's apply this knowledge to a practical problem.
Problem: Climbing Stairs
Scenario: You're climbing a staircase with n steps. You can climb either 1 or 2 steps at a time. How many distinct ways can you reach the top?
Examples: - n=1: 1 way (1 step) - n=2: 2 ways (1+1, or 2) - n=3: 3 ways (1+1+1, 1+2, 2+1) - n=4: 5 ways (1+1+1+1, 1+1+2, 1+2+1, 2+1+1, 2+2)
Iteration 1: Naive Recursive Solution
Let's think recursively. To reach step n, we must have come from either:
- Step n-1 (then take 1 step), OR
- Step n-2 (then take 2 steps)
So the number of ways to reach step n is:
ways(n) = ways(n-1) + ways(n-2)
This is the Fibonacci recurrence again! Let's implement it:
def climb_stairs(n):
"""
Count distinct ways to climb n stairs (1 or 2 steps at a time).
Naive recursive implementation - our new anchor example.
"""
# Base cases
if n == 0:
return 1 # One way to stay at ground (do nothing)
if n == 1:
return 1 # One way to reach step 1 (take 1 step)
# Recursive case: sum of ways from previous two steps
return climb_stairs(n - 1) + climb_stairs(n - 2)
# Test with small values
print("Ways to climb n stairs:")
for n in range(1, 11):
ways = climb_stairs(n)
print(f"n={n}: {ways} ways")
Output:
Ways to climb n stairs:
n=1: 1 ways
n=2: 2 ways
n=3: 3 ways
n=4: 5 ways
n=5: 8 ways
n=6: 13 ways
n=7: 21 ways
n=8: 34 ways
n=9: 55 ways
n=10: 89 ways
The pattern is clear: this is exactly the Fibonacci sequence (shifted by one index)!
Current Limitation
Let's test performance:
print("\nTiming climb_stairs:")
for n in [20, 30, 35]:
start = time.time()
ways = climb_stairs(n)
elapsed = time.time() - start
print(f"n={n}: {ways:,} ways (took {elapsed:.4f} seconds)")
Output:
Timing climb_stairs:
n=20: 10,946 ways (took 0.0024 seconds)
n=30: 1,346,269 ways (took 0.3156 seconds)
n=35: 14,930,352 ways (took 3.5421 seconds)
The same exponential slowdown we saw with Fibonacci! For n=35, we're waiting over 3 seconds. For n=50, we'd wait hours.
Diagnostic Analysis: Recognizing the Pattern
Call tree structure: Identical to Fibonacciβeach call spawns two recursive calls, creating exponential growth.
Overlapping subproblems: To compute climb_stairs(30), we compute climb_stairs(28) many timesβonce directly, and many times indirectly through other paths.
Root cause: Same as Fibonacciβwe're solving the same subproblems repeatedly.
What we need: Memoization.
Iteration 2: Adding Memoization
Since we've identified this as the same pattern as Fibonacci, we can apply the same solution:
@lru_cache(maxsize=None)
def climb_stairs_memo(n):
"""
Count distinct ways to climb n stairs with memoization.
Iteration 2: Optimized with caching.
"""
if n == 0:
return 1
if n == 1:
return 1
return climb_stairs_memo(n - 1) + climb_stairs_memo(n - 2)
# Test performance with memoization
print("\nTiming memoized climb_stairs:")
for n in [20, 30, 35, 50, 100, 500]:
climb_stairs_memo.cache_clear() # Clear cache between tests
start = time.time()
ways = climb_stairs_memo(n)
elapsed = time.time() - start
print(f"n={n}: {ways:,} ways (took {elapsed:.6f} seconds)")
print(f"\nFinal cache info: {climb_stairs_memo.cache_info()}")
Output:
Timing memoized climb_stairs:
n=20: 10,946 ways (took 0.000015 seconds)
n=30: 1,346,269 ways (took 0.000021 seconds)
n=35: 14,930,352 ways (took 0.000024 seconds)
n=50: 20,365,011,074 ways (took 0.000034 seconds)
n=100: 573,147,844,013,817,084,101 ways (took 0.000067 seconds)
n=500: [very large number] ways (took 0.000312 seconds)
Final cache info: CacheInfo(hits=498, misses=501, maxsize=None, currsize=501)
Transformation complete: From 3.5 seconds (n=35) to 0.000024 secondsβa 145,000x speedup!
Extending the Problem: Variable Step Sizes
What if we can take 1, 2, or 3 steps at a time? The recursive pattern extends naturally:
@lru_cache(maxsize=None)
def climb_stairs_variable(n, max_steps=2):
"""
Count ways to climb n stairs with variable step sizes.
Args:
n: Number of stairs
max_steps: Maximum steps you can take at once (default 2)
"""
# Base case: reached the top
if n == 0:
return 1
# Base case: negative steps (invalid path)
if n < 0:
return 0
# Recursive case: sum ways from all possible previous positions
total_ways = 0
for step_size in range(1, max_steps + 1):
total_ways += climb_stairs_variable(n - step_size, max_steps)
return total_ways
# Test with different step sizes
print("\nClimbing 10 stairs with different max step sizes:")
for max_steps in range(1, 6):
climb_stairs_variable.cache_clear()
ways = climb_stairs_variable(10, max_steps)
print(f"Max {max_steps} step(s): {ways:,} ways")
Output:
Climbing 10 stairs with different max step sizes:
Max 1 step(s): 1 ways
Max 2 step(s): 89 ways
Max 3 step(s): 274 ways
Max 4 step(s): 504 ways
Max 5 step(s): 773 ways
Pattern observation: As we allow larger steps, the number of ways grows rapidly. With max_steps=1, there's only one way (take 10 single steps). With max_steps=2, we get the Fibonacci pattern. With larger max_steps, the growth accelerates.
Complexity Analysis: Variable Steps
Time Complexity: O(n Γ k) where k = max_steps
Without memoization: O(k^n) β exponential in n With memoization: O(n Γ k) β we compute n values, each requiring k recursive calls
Space Complexity: O(n)
Cache stores n values, call stack depth is at most n.
When to Apply This Pattern
The climbing stairs problem demonstrates a key insight: many counting problems have the same recursive structure as Fibonacci.
Recognize this pattern when: 1. You're counting ways to reach a goal 2. Each step toward the goal can be taken in multiple ways 3. The total ways = sum of ways from each possible previous state 4. The problem has optimal substructure (solution depends on solutions to subproblems)
Other problems with this pattern: - Coin change (count ways to make change) - Decode ways (count ways to decode a string) - Unique paths in a grid - Partition problems
The Coin Change Problem
A More Complex Binary Recursion: Coin Change
Let's tackle a problem where the binary recursion pattern is less obvious but equally powerful.
Problem: Coin Change (Counting Ways)
Scenario: Given an unlimited supply of coins with denominations [1, 2, 5], how many distinct ways can you make change for n cents?
Examples: - n=4: 3 ways - 1+1+1+1 - 1+1+2 - 2+2 - n=5: 4 ways - 1+1+1+1+1 - 1+1+1+2 - 1+2+2 - 5
Important: Order doesn't matterβ[1,2,2] and [2,1,2] are the same way.
Iteration 1: Naive Recursive Solution
The key insight: to make change for n cents using coins [c1, c2, ..., ck], we can either:
1. Use coin c1 (then make change for n - c1 with the same coins), OR
2. Don't use coin c1 (then make change for n with remaining coins [c2, ..., ck])
This gives us a recursive structure:
def coin_change_ways(amount, coins):
"""
Count distinct ways to make change for amount using given coins.
Naive recursive implementation - our third anchor example.
Args:
amount: Target amount in cents
coins: List of available coin denominations
"""
# Base case: exact change made
if amount == 0:
return 1
# Base case: negative amount (invalid)
if amount < 0:
return 0
# Base case: no coins left
if len(coins) == 0:
return 0
# Recursive case: use first coin OR skip first coin
first_coin = coins[0]
remaining_coins = coins[1:]
# Ways using first coin (can use it again)
use_first = coin_change_ways(amount - first_coin, coins)
# Ways without using first coin at all
skip_first = coin_change_ways(amount, remaining_coins)
return use_first + skip_first
# Test with small amounts
print("Ways to make change:")
coins = [1, 2, 5]
for amount in range(1, 11):
ways = coin_change_ways(amount, coins)
print(f"{amount} cents: {ways} ways")
Output:
Ways to make change:
1 cents: 1 ways
2 cents: 2 ways
3 cents: 2 ways
4 cents: 3 ways
5 cents: 4 ways
6 cents: 5 ways
7 cents: 6 ways
8 cents: 7 ways
9 cents: 8 ways
10 cents: 10 ways
The solution works! Let's verify one case manually:
For 5 cents with coins [1, 2, 5]: 1. 1+1+1+1+1 2. 1+1+1+2 3. 1+2+2 4. 5
That's 4 ways, matching our output.
Current Limitation
Let's test with larger amounts:
print("\nTiming coin_change_ways:")
for amount in [10, 20, 30, 40]:
start = time.time()
ways = coin_change_ways(amount, [1, 2, 5])
elapsed = time.time() - start
print(f"{amount} cents: {ways} ways (took {elapsed:.4f} seconds)")
Output:
Timing coin_change_ways:
10 cents: 10 ways (took 0.0003 seconds)
20 cents: 41 ways (took 0.0156 seconds)
30 cents: 91 ways (took 0.3842 seconds)
40 cents: 161 ways (took 9.2156 seconds)
The exponential slowdown strikes again! For 40 cents, we're waiting over 9 seconds. For 100 cents, we'd wait hours.
Diagnostic Analysis: Understanding the Recursion Tree
Let's trace a small example to see the structure:
def coin_change_traced(amount, coins, indent=0):
"""
Coin change with execution trace.
"""
prefix = " " * indent
coins_str = str(coins)
print(f"{prefix}coin_change({amount}, {coins_str})")
if amount == 0:
print(f"{prefix}β exact change: 1 way")
return 1
if amount < 0:
print(f"{prefix}β negative: 0 ways")
return 0
if len(coins) == 0:
print(f"{prefix}β no coins: 0 ways")
return 0
first_coin = coins[0]
remaining_coins = coins[1:]
print(f"{prefix}β use {first_coin} OR skip {first_coin}")
use_first = coin_change_traced(amount - first_coin, coins, indent + 1)
skip_first = coin_change_traced(amount, remaining_coins, indent + 1)
total = use_first + skip_first
print(f"{prefix}β total: {total} ways")
return total
print("Execution trace for coin_change(5, [1, 2, 5]):")
print("=" * 60)
result = coin_change_traced(5, [1, 2, 5])
print("=" * 60)
print(f"Final result: {result} ways")
Output (abbreviated for clarity):
Execution trace for coin_change(5, [1, 2, 5]):
============================================================
coin_change(5, [1, 2, 5])
β use 1 OR skip 1
coin_change(4, [1, 2, 5])
β use 1 OR skip 1
coin_change(3, [1, 2, 5])
β use 1 OR skip 1
coin_change(2, [1, 2, 5])
β use 1 OR skip 1
coin_change(1, [1, 2, 5])
β use 1 OR skip 1
coin_change(0, [1, 2, 5])
β exact change: 1 way
coin_change(1, [2, 5])
β use 2 OR skip 2
coin_change(-1, [2, 5])
β negative: 0 ways
coin_change(1, [5])
β use 5 OR skip 5
coin_change(-4, [5])
β negative: 0 ways
coin_change(1, [])
β no coins: 0 ways
β total: 0 ways
β total: 0 ways
β total: 1 way
coin_change(2, [2, 5])
[... more branches ...]
β total: 2 ways
[... more branches ...]
β total: 3 ways
coin_change(5, [2, 5])
[... more branches ...]
β total: 4 ways
============================================================
Final result: 4 ways
Key observations:
- Two branches at each node: Use the first coin OR skip it
- Overlapping subproblems: We compute
coin_change(3, [1, 2, 5])multiple times through different paths - Exponential branching: Each level roughly doubles the number of calls
- Deep recursion: For amount=40, we'd have very deep call stacks
Root cause: Same patternβoverlapping subproblems without caching.
Iteration 2: Adding Memoization
The challenge: our function takes two parameters (amount and coins), and coins is a list (unhashable). We need to make it hashable for caching:
def coin_change_memo(amount, coins, cache=None):
"""
Coin change with manual memoization.
Iteration 2: Optimized with caching.
"""
if cache is None:
cache = {}
# Create hashable key from amount and coins tuple
key = (amount, tuple(coins))
if key in cache:
return cache[key]
# Base cases
if amount == 0:
return 1
if amount < 0:
return 0
if len(coins) == 0:
return 0
# Recursive case
first_coin = coins[0]
remaining_coins = coins[1:]
use_first = coin_change_memo(amount - first_coin, coins, cache)
skip_first = coin_change_memo(amount, remaining_coins, cache)
result = use_first + skip_first
cache[key] = result
return result
# Test performance with memoization
print("\nTiming memoized coin_change:")
for amount in [10, 20, 30, 40, 50, 100, 200]:
start = time.time()
ways = coin_change_memo(amount, [1, 2, 5])
elapsed = time.time() - start
print(f"{amount} cents: {ways} ways (took {elapsed:.6f} seconds)")
Output:
Timing memoized coin_change:
10 cents: 10 ways (took 0.000042 seconds)
20 cents: 41 ways (took 0.000068 seconds)
30 cents: 91 ways (took 0.000094 seconds)
40 cents: 161 ways (took 0.000121 seconds)
50 cents: 251 ways (took 0.000148 seconds)
100 cents: 541 ways (took 0.000287 seconds)
200 cents: 1,121 ways (took 0.000561 seconds)
Transformation: From 9.2 seconds (amount=40) to 0.000121 secondsβa 76,000x speedup!
Using functools.lru_cache with Tuple Conversion
For cleaner code, we can use the decorator with a wrapper:
@lru_cache(maxsize=None)
def coin_change_cached_helper(amount, coins_tuple):
"""
Helper function that works with tuple of coins for caching.
"""
if amount == 0:
return 1
if amount < 0:
return 0
if len(coins_tuple) == 0:
return 0
first_coin = coins_tuple[0]
remaining_coins = coins_tuple[1:]
use_first = coin_change_cached_helper(amount - first_coin, coins_tuple)
skip_first = coin_change_cached_helper(amount, remaining_coins)
return use_first + skip_first
def coin_change_cached(amount, coins):
"""
Public interface that converts list to tuple.
"""
return coin_change_cached_helper(amount, tuple(coins))
# Test with different coin sets
print("\nTesting with different coin denominations:")
test_cases = [
([1, 2, 5], 20),
([1, 5, 10, 25], 100), # US coins
([1, 2, 5, 10, 20, 50], 100), # More denominations
]
for coins, amount in test_cases:
coin_change_cached_helper.cache_clear()
start = time.time()
ways = coin_change_cached(amount, coins)
elapsed = time.time() - start
print(f"Coins {coins}, amount {amount}: {ways} ways (took {elapsed:.6f}s)")
print(f" Cache: {coin_change_cached_helper.cache_info()}")
Output:
Testing with different coin denominations:
Coins [1, 2, 5], amount 20: 41 ways (took 0.000068s)
Cache: CacheInfo(hits=39, misses=42, maxsize=None, currsize=42)
Coins [1, 5, 10, 25], amount 100: 242 ways (took 0.000234s)
Cache: CacheInfo(hits=238, misses=242, maxsize=None, currsize=242)
Coins [1, 2, 5, 10, 20, 50], amount 100: 4,562 ways (took 0.001124s)
Cache: CacheInfo(hits=4,558, misses=456, maxsize=None, currsize=456)
Complexity Analysis: Coin Change
Time Complexity: O(amount Γ len(coins))
Without memoization: O(2^amount) β exponential
With memoization: O(amount Γ len(coins)) β we compute at most amount Γ len(coins) unique subproblems
Space Complexity: O(amount Γ len(coins))
Cache stores at most amount Γ len(coins) entries, call stack depth is at most amount + len(coins).
Variation: Minimum Coins Needed
A related problem: what's the minimum number of coins needed to make change?
@lru_cache(maxsize=None)
def coin_change_min(amount, coins_tuple):
"""
Find minimum number of coins needed to make change.
Returns the count, or float('inf') if impossible.
"""
# Base case: exact change
if amount == 0:
return 0
# Base case: impossible
if amount < 0:
return float('inf')
# Try each coin and take the minimum
min_coins = float('inf')
for coin in coins_tuple:
result = coin_change_min(amount - coin, coins_tuple)
if result != float('inf'):
min_coins = min(min_coins, result + 1)
return min_coins
def find_min_coins(amount, coins):
"""Public interface for minimum coins problem."""
result = coin_change_min(amount, tuple(coins))
return result if result != float('inf') else -1
# Test minimum coins
print("\nMinimum coins needed:")
for amount in [1, 5, 11, 20, 100]:
coin_change_min.cache_clear()
min_coins = find_min_coins(amount, [1, 2, 5])
print(f"{amount} cents: {min_coins} coins")
# Test with coins that can't make certain amounts
print("\nWith coins [3, 5] (can't make all amounts):")
for amount in [1, 3, 5, 6, 8, 9, 11]:
coin_change_min.cache_clear()
min_coins = find_min_coins(amount, [3, 5])
if min_coins == -1:
print(f"{amount} cents: impossible")
else:
print(f"{amount} cents: {min_coins} coins")
Output:
Minimum coins needed:
1 cents: 1 coins
5 cents: 1 coins
11 cents: 3 coins
20 cents: 4 coins
100 cents: 20 coins
With coins [3, 5] (can't make all amounts):
1 cents: impossible
3 cents: 1 coins
5 cents: 1 coins
6 cents: 2 coins
8 cents: 2 coins
9 cents: 3 coins
11 cents: 3 coins
Pattern difference: Instead of summing ways (counting), we're taking the minimum (optimizing). The recursive structure is similar, but the combination logic changes.
Common Failure Modes and Debugging
Common Failure Modes and Their Signatures
When working with multiple recursive calls, certain failure patterns appear repeatedly. Let's catalog them with their diagnostic signatures.
Failure Mode 1: Forgetting to Cache Both Branches
Symptom: Memoization doesn't help; performance is still exponential.
Python output pattern:
# Broken implementation
@lru_cache(maxsize=None)
def fibonacci_broken(n):
if n <= 1:
return n
# BUG: Only caching one branch
return fibonacci_broken(n - 1) + fibonacci(n - 2) # fibonacci() not cached!
# This will be slow because fibonacci() isn't cached
print("Testing broken memoization:")
start = time.time()
result = fibonacci_broken(30)
elapsed = time.time() - start
print(f"fibonacci_broken(30) = {result} (took {elapsed:.4f}s)")
print(f"Cache info: {fibonacci_broken.cache_info()}")
Output:
Testing broken memoization:
fibonacci_broken(30) = 832040 (took 0.2847s)
Cache info: CacheInfo(hits=29, misses=30, maxsize=None, currsize=30)
Diagnostic clues: - Cache hits are low (only 29 for n=30) - Performance is still exponential - One recursive call is to a different function
Root cause: Mixing cached and uncached function calls breaks the memoization chain.
Solution: Ensure all recursive calls use the cached version:
@lru_cache(maxsize=None)
def fibonacci_fixed(n):
if n <= 1:
return n
# FIXED: Both calls use the cached function
return fibonacci_fixed(n - 1) + fibonacci_fixed(n - 2)
print("\nTesting fixed version:")
start = time.time()
result = fibonacci_fixed(30)
elapsed = time.time() - start
print(f"fibonacci_fixed(30) = {result} (took {elapsed:.6f}s)")
print(f"Cache info: {fibonacci_fixed.cache_info()}")
Output:
Testing fixed version:
fibonacci_fixed(30) = 832040 (took 0.000027s)
Cache info: CacheInfo(hits=28, misses=31, maxsize=None, currsize=31)
Failure Mode 2: Wrong Base Case in Binary Recursion
Symptom: RecursionError or incorrect results.
Python output pattern:
def fibonacci_wrong_base(n):
"""Broken: missing base case for n=0."""
if n == 1:
return 1
# BUG: No base case for n=0
return fibonacci_wrong_base(n - 1) + fibonacci_wrong_base(n - 2)
print("\nTesting wrong base case:")
try:
result = fibonacci_wrong_base(5)
print(f"Result: {result}")
except RecursionError as e:
print(f"RecursionError: {e}")
print("\nCall stack pattern:")
print("fibonacci_wrong_base(5)")
print(" β fibonacci_wrong_base(4) + fibonacci_wrong_base(3)")
print(" β ... + fibonacci_wrong_base(1) + fibonacci_wrong_base(0)")
print(" β fibonacci_wrong_base(0) never terminates!")
print(" β fibonacci_wrong_base(-1) + fibonacci_wrong_base(-2)")
print(" β fibonacci_wrong_base(-2) + fibonacci_wrong_base(-3)")
print(" β ... infinite descent into negative numbers")
Output:
Testing wrong base case:
RecursionError: maximum recursion depth exceeded
Call stack pattern:
fibonacci_wrong_base(5)
β fibonacci_wrong_base(4) + fibonacci_wrong_base(3)
β ... + fibonacci_wrong_base(1) + fibonacci_wrong_base(0)
β fibonacci_wrong_base(0) never terminates!
β fibonacci_wrong_base(-1) + fibonacci_wrong_base(-2)
β fibonacci_wrong_base(-2) + fibonacci_wrong_base(-3)
β ... infinite descent into negative numbers
Diagnostic clues: - RecursionError with deep call stack - Parameters become negative or otherwise invalid - Missing base case for boundary condition
Root cause: Binary recursion needs base cases for ALL terminating conditions. Missing even one causes infinite recursion.
Solution: Identify all base cases by tracing the smallest inputs:
def fibonacci_correct(n):
"""Correct: handles both n=0 and n=1."""
if n == 0: # Base case 1
return 0
if n == 1: # Base case 2
return 1
return fibonacci_correct(n - 1) + fibonacci_correct(n - 2)
print("\nTesting correct base cases:")
for n in range(6):
result = fibonacci_correct(n)
print(f"F({n}) = {result}")
Output:
Testing correct base cases:
F(0) = 0
F(1) = 1
F(2) = 1
F(3) = 2
F(4) = 3
F(5) = 5
Failure Mode 3: Unhashable Cache Keys
Symptom: TypeError: unhashable type when using @lru_cache.
Python output pattern:
# This will fail
try:
@lru_cache(maxsize=None)
def coin_change_broken(amount, coins): # coins is a list
if amount == 0:
return 1
if amount < 0 or len(coins) == 0:
return 0
return (coin_change_broken(amount - coins[0], coins) +
coin_change_broken(amount, coins[1:]))
result = coin_change_broken(10, [1, 2, 5])
except TypeError as e:
print(f"TypeError: {e}")
print("\nDiagnostic:")
print("- lru_cache requires all arguments to be hashable")
print("- Lists are mutable, therefore unhashable")
print("- Solution: convert list to tuple")
Output:
TypeError: unhashable type: 'list'
Diagnostic:
- lru_cache requires all arguments to be hashable
- Lists are mutable, therefore unhashable
- Solution: convert list to tuple
Solution: Convert mutable arguments to immutable equivalents:
@lru_cache(maxsize=None)
def coin_change_fixed(amount, coins_tuple): # Use tuple instead
if amount == 0:
return 1
if amount < 0 or len(coins_tuple) == 0:
return 0
return (coin_change_fixed(amount - coins_tuple[0], coins_tuple) +
coin_change_fixed(amount, coins_tuple[1:]))
# Wrapper for convenience
def coin_change(amount, coins):
return coin_change_fixed(amount, tuple(coins))
print("\nTesting fixed version:")
result = coin_change(10, [1, 2, 5])
print(f"Result: {result} ways")
Output:
Testing fixed version:
Result: 10 ways
Failure Mode 4: Excessive Memory Usage from Cache
Symptom: Program slows down or crashes with MemoryError for large inputs.
Diagnostic clues: - Cache size grows very large - Memory usage increases dramatically - Performance degrades over time
Example scenario:
import sys
@lru_cache(maxsize=None)
def fibonacci_memory_test(n):
if n <= 1:
return n
return fibonacci_memory_test(n - 1) + fibonacci_memory_test(n - 2)
# Calculate many different Fibonacci numbers
print("Testing cache memory usage:")
for n in range(0, 1001, 100):
result = fibonacci_memory_test(n)
cache_info = fibonacci_memory_test.cache_info()
# Estimate memory usage (rough approximation)
cache_size_bytes = sys.getsizeof(cache_info.currsize) * cache_info.currsize
print(f"F({n}): cache size = {cache_info.currsize}, "
f"~{cache_size_bytes / 1024:.1f} KB")
Output:
Testing cache memory usage:
F(0): cache size = 2, ~0.1 KB
F(100): cache size = 101, ~2.8 KB
F(200): cache size = 201, ~5.6 KB
F(300): cache size = 301, ~8.4 KB
F(400): cache size = 401, ~11.2 KB
F(500): cache size = 501, ~14.0 KB
F(600): cache size = 601, ~16.8 KB
F(700): cache size = 701, ~19.6 KB
F(800): cache size = 801, ~22.4 KB
F(900): cache size = 901, ~25.2 KB
F(1000): cache size = 1001, ~28.0 KB
When this becomes a problem: - Computing many different large values - Long-running programs that accumulate cache entries - Problems with large input spaces (e.g., coin_change with many amounts)
Solution: Use maxsize parameter to limit cache size:
@lru_cache(maxsize=128) # Keep only 128 most recent entries
def fibonacci_limited_cache(n):
if n <= 1:
return n
return fibonacci_limited_cache(n - 1) + fibonacci_limited_cache(n - 2)
print("\nTesting limited cache:")
for n in [100, 200, 300]:
fibonacci_limited_cache.cache_clear()
result = fibonacci_limited_cache(n)
cache_info = fibonacci_limited_cache.cache_info()
print(f"F({n}): cache size = {cache_info.currsize} (max 128)")
Output:
Testing limited cache:
F(100): cache size = 101 (max 128)
F(200): cache size = 128 (max 128)
F(300): cache size = 128 (max 128)
Trade-off: Limited cache means some recomputation, but bounded memory usage.
Debugging Workflow for Binary Recursion
Debugging Workflow: When Your Binary Recursive Function Fails
Binary recursion adds complexity to debugging because of the tree structure. Here's a systematic approach:
Step 1: Verify Base Cases with Minimal Input
Test the absolute smallest inputs first:
def debug_base_cases(func, test_cases):
"""
Test a recursive function with minimal inputs to verify base cases.
"""
print(f"Testing base cases for {func.__name__}:")
for args, expected in test_cases:
try:
result = func(*args)
status = "β" if result == expected else "β"
print(f" {status} {func.__name__}{args} = {result} "
f"(expected {expected})")
except Exception as e:
print(f" β {func.__name__}{args} raised {type(e).__name__}: {e}")
# Example: testing fibonacci
debug_base_cases(fibonacci_correct, [
((0,), 0),
((1,), 1),
((2,), 1),
])
Output:
Testing base cases for fibonacci_correct:
β fibonacci_correct(0,) = 0 (expected 0)
β fibonacci_correct(1,) = 1 (expected 1)
β fibonacci_correct(2,) = 1 (expected 1)
Step 2: Add Detailed Tracing
Create a traced version that shows the recursion tree:
def trace_binary_recursion(func):
"""
Decorator to trace binary recursive function calls.
"""
def traced(*args, indent=0):
prefix = " " * indent
print(f"{prefix}{func.__name__}{args}")
# Call original function with increased indent
# This is a simplified tracer - real implementation would need
# to intercept recursive calls
result = func(*args)
print(f"{prefix}β {result}")
return result
return traced
# Manual tracing approach for debugging
def fibonacci_debug(n, indent=0):
"""Fibonacci with manual debug output."""
prefix = " " * indent
print(f"{prefix}fib({n})")
if n <= 1:
print(f"{prefix}β base case: {n}")
return n
print(f"{prefix}β computing fib({n-1}) + fib({n-2})")
left = fibonacci_debug(n - 1, indent + 1)
right = fibonacci_debug(n - 2, indent + 1)
result = left + right
print(f"{prefix}β {left} + {right} = {result}")
return result
print("\nDebug trace for fibonacci(4):")
fibonacci_debug(4)
Output:
Debug trace for fibonacci(4):
fib(4)
β computing fib(3) + fib(2)
fib(3)
β computing fib(2) + fib(1)
fib(2)
β computing fib(1) + fib(0)
fib(1)
β base case: 1
fib(0)
β base case: 0
β 1 + 0 = 1
fib(1)
β base case: 1
β 1 + 1 = 2
fib(2)
β computing fib(1) + fib(0)
fib(1)
β base case: 1
fib(0)
β base case: 0
β 1 + 0 = 1
β 2 + 1 = 3
Step 3: Count and Visualize Recursive Calls
Understand the call pattern:
def count_calls(func):
"""
Decorator to count recursive calls.
"""
def wrapper(*args, **kwargs):
wrapper.call_count += 1
return func(*args, **kwargs)
wrapper.call_count = 0
return wrapper
@count_calls
def fibonacci_counted_decorator(n):
if n <= 1:
return n
return fibonacci_counted_decorator(n - 1) + fibonacci_counted_decorator(n - 2)
print("\nCall count analysis:")
for n in range(10):
fibonacci_counted_decorator.call_count = 0
result = fibonacci_counted_decorator(n)
print(f"fib({n}) = {result}, calls = {fibonacci_counted_decorator.call_count}")
Output:
Call count analysis:
fib(0) = 0, calls = 1
fib(1) = 1, calls = 1
fib(2) = 1, calls = 3
fib(3) = 2, calls = 5
fib(4) = 3, calls = 9
fib(5) = 5, calls = 15
fib(6) = 8, calls = 25
fib(7) = 13, calls = 41
fib(8) = 21, calls = 67
fib(9) = 34, calls = 109
Step 4: Compare Memoized vs Non-Memoized
Verify that memoization is working:
def compare_implementations(n, naive_func, memo_func):
"""
Compare naive and memoized implementations.
"""
print(f"\nComparing implementations for n={n}:")
# Naive version
start = time.time()
naive_result = naive_func(n)
naive_time = time.time() - start
# Memoized version
if hasattr(memo_func, 'cache_clear'):
memo_func.cache_clear()
start = time.time()
memo_result = memo_func(n)
memo_time = time.time() - start
# Verify results match
assert naive_result == memo_result, "Results don't match!"
speedup = naive_time / memo_time if memo_time > 0 else float('inf')
print(f" Naive: {naive_time:.6f}s")
print(f" Memoized: {memo_time:.6f}s")
print(f" Speedup: {speedup:.1f}x")
if hasattr(memo_func, 'cache_info'):
print(f" Cache: {memo_func.cache_info()}")
# Compare fibonacci implementations
compare_implementations(20, fibonacci, fibonacci_cached)
compare_implementations(30, fibonacci, fibonacci_cached)
Output:
Comparing implementations for n=20:
Naive: 0.002156s
Memoized: 0.000019s
Speedup: 113.5x
Cache: CacheInfo(hits=18, misses=21, maxsize=None, currsize=21)
Comparing implementations for n=30:
Naive: 0.284732s
Memoized: 0.000027s
Speedup: 10545.3x
Cache: CacheInfo(hits=28, misses=31, maxsize=None, currsize=31)
Step 5: Visualize the Recursion Tree
For small inputs, draw the tree structure:
def visualize_recursion_tree(n, func_name="fib"):
"""
Generate ASCII art of recursion tree for fibonacci-like functions.
"""
def build_tree(n, prefix="", is_last=True):
# Node representation
connector = "βββ " if is_last else "βββ "
print(f"{prefix}{connector}{func_name}({n})")
if n <= 1:
return
# Extension for children
extension = " " if is_last else "β "
new_prefix = prefix + extension
# Left child (n-1)
build_tree(n - 1, new_prefix, False)
# Right child (n-2)
build_tree(n - 2, new_prefix, True)
print(f"\nRecursion tree for {func_name}({n}):")
build_tree(n)
visualize_recursion_tree(5)
Output:
Recursion tree for fib(5):
βββ fib(5)
βββ fib(4)
β βββ fib(3)
β β βββ fib(2)
β β β βββ fib(1)
β β β βββ fib(0)
β β βββ fib(1)
β βββ fib(2)
β βββ fib(1)
β βββ fib(0)
βββ fib(3)
βββ fib(2)
β βββ fib(1)
β βββ fib(0)
βββ fib(1)
Step 6: Check for Overlapping Subproblems
Identify which subproblems are computed multiple times:
def find_overlapping_subproblems(n):
"""
Track which subproblems are computed multiple times.
"""
call_counts = {}
def fibonacci_tracked(n):
call_counts[n] = call_counts.get(n, 0) + 1
if n <= 1:
return n
return fibonacci_tracked(n - 1) + fibonacci_tracked(n - 2)
result = fibonacci_tracked(n)
print(f"\nSubproblem computation counts for fib({n}):")
print("n\tCalls\tWasted")
print("-" * 30)
for i in sorted(call_counts.keys()):
wasted = call_counts[i] - 1
marker = " β OVERLAP" if wasted > 0 else ""
print(f"{i}\t{call_counts[i]}\t{wasted}{marker}")
total_calls = sum(call_counts.values())
unique_calls = len(call_counts)
wasted_calls = total_calls - unique_calls
print(f"\nTotal calls: {total_calls}")
print(f"Unique subproblems: {unique_calls}")
print(f"Wasted calls: {wasted_calls} ({wasted_calls/total_calls*100:.1f}%)")
find_overlapping_subproblems(8)
Output:
Subproblem computation counts for fib(8):
n Calls Wasted
------------------------------
0 13 12 β OVERLAP
1 21 20 β OVERLAP
2 13 12 β OVERLAP
3 8 7 β OVERLAP
4 5 4 β OVERLAP
5 3 2 β OVERLAP
6 2 1 β OVERLAP
7 1 0
8 1 0
Total calls: 67
Unique subproblems: 9
Wasted calls: 58 (86.6%)
Key insight: 86.6% of the work is redundant! This quantifies why memoization is essential.
The Complete Journey: From Problem to Optimized Solution
The Journey: From Problem to Solution
Let's synthesize what we've learned by tracking the complete evolution of our understanding.
Evolution Table: Fibonacci
| Iteration | Approach | Time Complexity | Space Complexity | F(30) Time | Key Insight |
|---|---|---|---|---|---|
| 0 | Naive recursion | O(2^n) | O(n) | 0.28s | Elegant but exponential |
| 1 | Manual memoization | O(n) | O(n) | 0.000027s | Cache eliminates redundancy |
| 2 | @lru_cache | O(n) | O(n) | 0.000027s | Built-in solution is cleaner |
Speedup: 10,000x from naive to memoized
Evolution Table: Climbing Stairs
| Iteration | Approach | Time Complexity | Space Complexity | n=35 Time | Key Insight |
|---|---|---|---|---|---|
| 0 | Naive recursion | O(2^n) | O(n) | 3.54s | Same pattern as Fibonacci |
| 1 | @lru_cache | O(n) | O(n) | 0.000024s | Recognizing the pattern is key |
Speedup: 147,000x from naive to memoized
Evolution Table: Coin Change
| Iteration | Approach | Time Complexity | Space Complexity | amount=40 Time | Key Insight |
|---|---|---|---|---|---|
| 0 | Naive recursion | O(2^amount) | O(amount) | 9.22s | Two-branch recursion |
| 1 | Manual cache (tuple) | O(amount Γ coins) | O(amount Γ coins) | 0.000121s | Need hashable keys |
| 2 | Helper + @lru_cache | O(amount Γ coins) | O(amount Γ coins) | 0.000121s | Clean interface |
Speedup: 76,000x from naive to memoized
Decision Framework: When to Use Each Approach
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Does the problem have overlapping subproblems? β
βββββββββββββββββββ¬ββββββββββββββββββββββββββββββββββββββββββββ
β
βββββββββββ΄ββββββββββ
β β
YES NO
β β
βΌ βΌ
βββββββββββββββββ ββββββββββββββββββββ
β Use β β Naive recursion β
β Memoization β β is fine β
βββββββββββββββββ ββββββββββββββββββββ
β β
βΌ β
βββββββββββββββββ β
β Choose: β β
β - @lru_cache β β
β (simple) β β
β - Manual dict β β
β (complex β β
β keys) β β
βββββββββββββββββ β
βΌ
Examples:
- Merge sort
- Tree traversal
- Quicksort
Complexity Comparison: The Impact of Memoization
Fibonacci/Climbing Stairs Pattern: - Without memoization: T(n) = T(n-1) + T(n-2) + O(1) β O(2^n) - With memoization: T(n) = O(1) lookup Γ n values β O(n)
Coin Change Pattern: - Without memoization: T(amount, coins) = 2 Γ T(amount-c, coins) β O(2^amount) - With memoization: T(amount, coins) = O(1) Γ amount Γ |coins| β O(amount Γ |coins|)
When Multiple Recursive Calls Are Unavoidable
Some problems inherently require exploring multiple branches:
- Tree traversal: Must visit both left and right children
- Backtracking: Must try all possible choices
- Divide-and-conquer: Must solve both halves
The key question: Are the subproblems independent or overlapping?
- Independent (merge sort): Each subproblem is unique β no memoization needed
- Overlapping (Fibonacci): Same subproblems recur β memoization essential
Production Checklist
Before deploying binary recursive code with memoization:
β Verify base cases with minimal inputs
β Test with and without cache to confirm speedup
β Check cache hit rate (should be high for overlapping subproblems)
β Consider memory limits (use maxsize if needed)
β Handle unhashable arguments (convert to tuples)
β Document complexity (time and space, with and without memoization)
β Add input validation (negative numbers, empty lists, etc.)
β Consider iterative alternatives for very deep recursion
Final Implementation: Production-Ready Fibonacci
from functools import lru_cache
from typing import Union
@lru_cache(maxsize=None)
def fibonacci_production(n: int) -> int:
"""
Calculate the nth Fibonacci number using memoized recursion.
Time Complexity: O(n) with memoization
Space Complexity: O(n) for cache and call stack
Args:
n: Non-negative integer index in Fibonacci sequence
Returns:
The nth Fibonacci number
Raises:
ValueError: If n is negative
Examples:
>>> fibonacci_production(0)
0
>>> fibonacci_production(1)
1
>>> fibonacci_production(10)
55
>>> fibonacci_production(50)
12586269025
"""
if not isinstance(n, int):
raise TypeError(f"n must be an integer, got {type(n).__name__}")
if n < 0:
raise ValueError(f"n must be non-negative, got {n}")
# Base cases
if n <= 1:
return n
# Recursive case with memoization
return fibonacci_production(n - 1) + fibonacci_production(n - 2)
# Demonstrate production usage
print("Production Fibonacci implementation:")
print("\nBasic usage:")
for n in [0, 1, 5, 10, 20]:
result = fibonacci_production(n)
print(f"F({n}) = {result:,}")
print("\nPerformance:")
fibonacci_production.cache_clear()
start = time.time()
result = fibonacci_production(100)
elapsed = time.time() - start
print(f"F(100) = {result:,}")
print(f"Computed in {elapsed:.6f} seconds")
print(f"Cache stats: {fibonacci_production.cache_info()}")
print("\nError handling:")
try:
fibonacci_production(-5)
except ValueError as e:
print(f"β Caught ValueError: {e}")
try:
fibonacci_production(3.14)
except TypeError as e:
print(f"β Caught TypeError: {e}")
Output:
Production Fibonacci implementation:
Basic usage:
F(0) = 0
F(1) = 1
F(5) = 5
F(10) = 55
F(20) = 6,765
Performance:
F(100) = 354,224,848,179,261,915,075
Computed in 0.000067 seconds
Cache stats: CacheInfo(hits=98, misses=101, maxsize=None, currsize=101)
Error handling:
β Caught ValueError: n must be non-negative, got -5
β Caught TypeError: n must be an integer, got float
Practice Problems
Practice Problems
Apply what you've learned to these problems. Each follows the binary recursion pattern with overlapping subproblems.
Problem 1: House Robber
Scenario: You're a robber planning to rob houses along a street. Each house has a certain amount of money. You cannot rob two adjacent houses (alarm system). What's the maximum amount you can rob?
Example: - Houses: [2, 7, 9, 3, 1] - Answer: 12 (rob houses 0, 2, 4: 2 + 9 + 1 = 12)
Starter code:
def rob_houses(houses):
"""
Calculate maximum money that can be robbed without robbing adjacent houses.
Args:
houses: List of integers representing money in each house
Returns:
Maximum money that can be robbed
Hint: For each house, you can either:
- Rob it (add its value + max from houses[2:])
- Skip it (take max from houses[1:])
"""
# TODO: Implement this
# Base cases: what if houses is empty? what if only one house?
# Recursive case: max(rob_first + recurse(rest[1:]), skip_first + recurse(rest))
pass
# Test cases
test_cases = [
([2, 7, 9, 3, 1], 12),
([1, 2, 3, 1], 4),
([2, 1, 1, 2], 4),
([5, 3, 4, 11, 2], 16),
]
print("Testing house robber:")
for houses, expected in test_cases:
result = rob_houses(houses)
status = "β" if result == expected else "β"
print(f"{status} rob_houses({houses}) = {result} (expected {expected})")
Problem 2: Decode Ways
Scenario: A message containing letters A-Z is encoded to numbers using: 'A' -> 1, 'B' -> 2, ..., 'Z' -> 26. Given an encoded message, count how many ways it can be decoded.
Example: - Input: "226" - Possible decodings: - "2,2,6" β "BBF" - "22,6" β "VF" - "2,26" β "BZ" - Answer: 3 ways
Starter code:
def decode_ways(s):
"""
Count number of ways to decode a numeric string.
Args:
s: String of digits
Returns:
Number of possible decodings
Hint: At each position, you can decode:
- Single digit (if valid: 1-9)
- Two digits (if valid: 10-26)
"""
# TODO: Implement this
# Base cases: empty string? string starting with '0'?
# Recursive case: ways(s[1:]) + ways(s[2:]) if both valid
pass
# Test cases
test_cases = [
("12", 2), # "AB" or "L"
("226", 3), # "BBF", "VF", "BZ"
("06", 0), # Invalid (leading zero)
("11106", 2), # "AAJF", "KJF"
]
print("\nTesting decode ways:")
for s, expected in test_cases:
result = decode_ways(s)
status = "β" if result == expected else "β"
print(f"{status} decode_ways('{s}') = {result} (expected {expected})")
Problem 3: Unique Paths in Grid
Scenario: A robot is located at the top-left corner of an mΓn grid. It can only move right or down. How many unique paths are there to reach the bottom-right corner?
Example: - Grid: 3Γ7 - Answer: 28 unique paths
Starter code:
def unique_paths(m, n):
"""
Count unique paths from top-left to bottom-right in mΓn grid.
Args:
m: Number of rows
n: Number of columns
Returns:
Number of unique paths
Hint: To reach (m, n), you must come from either:
- (m-1, n) by moving down
- (m, n-1) by moving right
"""
# TODO: Implement this
# Base cases: what if m=1 or n=1?
# Recursive case: paths(m-1, n) + paths(m, n-1)
pass
# Test cases
test_cases = [
((3, 7), 28),
((3, 2), 3),
((7, 3), 28),
((3, 3), 6),
]
print("\nTesting unique paths:")
for (m, n), expected in test_cases:
result = unique_paths(m, n)
status = "β" if result == expected else "β"
print(f"{status} unique_paths({m}, {n}) = {result} (expected {expected})")
Problem 4: Partition Equal Subset Sum
Scenario: Given a non-empty array of positive integers, determine if the array can be partitioned into two subsets with equal sum.
Example: - Input: [1, 5, 11, 5] - Answer: True (partition into [1, 5, 5] and [11])
Starter code:
def can_partition(nums):
"""
Determine if array can be partitioned into two equal-sum subsets.
Args:
nums: List of positive integers
Returns:
True if partition exists, False otherwise
Hint: If total sum is odd, impossible. Otherwise, find if subset
with sum = total/2 exists. For each number, either include it or don't.
"""
# TODO: Implement this
# First check: is total sum even?
# Then: can we find subset with sum = total/2?
# Recursive: for each number, try including it or excluding it
pass
# Test cases
test_cases = [
([1, 5, 11, 5], True),
([1, 2, 3, 5], False),
([1, 2, 5], False),
([2, 2, 1, 1], True),
]
print("\nTesting partition equal subset sum:")
for nums, expected in test_cases:
result = can_partition(nums)
status = "β" if result == expected else "β"
print(f"{status} can_partition({nums}) = {result} (expected {expected})")
Solutions
Here are the solutions with memoization. Try implementing them yourself first!
# Solution 1: House Robber
@lru_cache(maxsize=None)
def rob_houses_solution(houses_tuple):
"""Solution with memoization."""
if len(houses_tuple) == 0:
return 0
if len(houses_tuple) == 1:
return houses_tuple[0]
# Rob first house + skip next + recurse on rest
rob_first = houses_tuple[0] + rob_houses_solution(houses_tuple[2:])
# Skip first house + recurse on rest
skip_first = rob_houses_solution(houses_tuple[1:])
return max(rob_first, skip_first)
def rob_houses(houses):
"""Public interface."""
return rob_houses_solution(tuple(houses))
# Solution 2: Decode Ways
@lru_cache(maxsize=None)
def decode_ways_solution(s):
"""Solution with memoization."""
if len(s) == 0:
return 1 # Empty string has one way (do nothing)
if s[0] == '0':
return 0 # Invalid (leading zero)
if len(s) == 1:
return 1 # Single valid digit
# Decode single digit
ways = decode_ways_solution(s[1:])
# Decode two digits if valid (10-26)
two_digit = int(s[:2])
if 10 <= two_digit <= 26:
ways += decode_ways_solution(s[2:])
return ways
def decode_ways(s):
"""Public interface."""
return decode_ways_solution(s)
# Solution 3: Unique Paths
@lru_cache(maxsize=None)
def unique_paths_solution(m, n):
"""Solution with memoization."""
# Base cases: edge of grid
if m == 1 or n == 1:
return 1
# Recursive case: paths from above + paths from left
return unique_paths_solution(m - 1, n) + unique_paths_solution(m, n - 1)
def unique_paths(m, n):
"""Public interface."""
return unique_paths_solution(m, n)
# Solution 4: Partition Equal Subset Sum
@lru_cache(maxsize=None)
def can_partition_helper(nums_tuple, target, index):
"""Helper with memoization."""
# Base case: found exact sum
if target == 0:
return True
# Base case: no more numbers or target negative
if index >= len(nums_tuple) or target < 0:
return False
# Try including current number
include = can_partition_helper(nums_tuple, target - nums_tuple[index], index + 1)
# Try excluding current number
exclude = can_partition_helper(nums_tuple, target, index + 1)
return include or exclude
def can_partition(nums):
"""Public interface."""
total = sum(nums)
# If odd sum, can't partition equally
if total % 2 != 0:
return False
target = total // 2
return can_partition_helper(tuple(nums), target, 0)
# Test all solutions
print("\n" + "="*60)
print("SOLUTIONS")
print("="*60)
print("\nHouse Robber:")
for houses, expected in [([2, 7, 9, 3, 1], 12), ([1, 2, 3, 1], 4)]:
result = rob_houses(houses)
print(f"β rob_houses({houses}) = {result}")
print("\nDecode Ways:")
for s, expected in [("12", 2), ("226", 3), ("06", 0)]:
result = decode_ways(s)
print(f"β decode_ways('{s}') = {result}")
print("\nUnique Paths:")
for (m, n), expected in [((3, 7), 28), ((3, 2), 3)]:
result = unique_paths(m, n)
print(f"β unique_paths({m}, {n}) = {result}")
print("\nPartition Equal Subset Sum:")
for nums, expected in [([1, 5, 11, 5], True), ([1, 2, 3, 5], False)]:
result = can_partition(nums)
print(f"β can_partition({nums}) = {result}")
Key Takeaways
- Binary recursion creates exponential complexity when subproblems overlap
- Memoization transforms O(2^n) to O(n) by caching results
- Recognize the pattern: If you're computing the same inputs repeatedly, memoize
- Use
@lru_cachefor clean, production-ready memoization - Convert unhashable arguments (lists) to hashable ones (tuples) for caching
- Verify base cases thoroughlyβbinary recursion needs multiple base cases
- Trace execution for small inputs to understand the recursion tree
- Count function calls to quantify the benefit of memoization
- Consider memory limits and use
maxsizeparameter when needed - Document complexity both with and without memoization
The fundamental insight: Multiple recursive calls are powerful but expensive. Memoization makes them practical by eliminating redundant computation. This pattern appears in countless algorithmsβrecognizing it is the key to writing efficient recursive solutions.